昨天讓 Gemini 開口說了第一句話。
但那句話是憋了好幾秒,才一口氣爆出來的。
今天,就是要把這口氣,順過來。
在昨天等待的時間裡,終端機畫面完全靜止,什麼反應都沒有。使用者不知道它是「正在想」還是「卡住了」,這種不確定感,比實際的等待時間更難受。
打開 Gemini 官方網頁版對話,會發現文字是一個字一個字蹦出來的,這種「看得到進度」的感覺,背後的技術名詞就是 Streaming(串流):伺服器不等整段內容生成完畢,而是生成一小塊、就送一小塊過來。
今天的目標:讓 DevPulse 也有這種呼吸感。
好消息是,Google 的 Interactions API 本身就內建串流支援,不用額外安裝任何東西。
差別只在呼叫 ai.interactions.create() 時,多加一個 stream: true:
const stream = await ai.interactions.create({
model: "gemini-3-flash-preview",
input: "...",
stream: true,
});
但要注意,這時候回傳的不再是昨天那種「一個裝好答案的物件」,而是一個事件流,需要用 for await...of 一個一個把事件收下來。
第一次寫的時候,我天真地以為「收到的東西就是文字」,直接全部印出來,結果畫面出現了幾個看不懂的符號片段。
查了文件才發現,串流傳回來的其實是好幾種不同類型的事件,包含流程開始、狀態更新、每一小步的內容增量等等,其中負責文字內容的事件類型是 step.delta,而且 delta 底下還會區分是不是文字類型。
Gemini 3 系列模型在正式回答前,還會有一段內部的思考步驟,這段思考也會送出事件,但它的 delta.type 不是 "text"。如果沒有過濾,畫面上就會混進這些不該被使用者看到的內容。
所以真正該印出來的,只有同時符合這兩個條件的事件:event_type 是 "step.delta",而且 delta.type 是 "text"。
修正過濾條件之後,完整代碼長這樣:
import 'dotenv/config';
import { GoogleGenAI } from "@google/genai";
const ai = new GoogleGenAI({});
async function main() {
const stream = await ai.interactions.create({
model: "gemini-3-flash-preview",
input: "用大概 80 字解釋什麼是 Streaming Response,語氣像在跟朋友聊天。",
generation_config: {
thinking_level: "minimal", // 為什麼要加這行,見六、踩坑記錄
},
stream: true,
});
for await (const event of stream) {
if (event.event_type === "step.delta" && event.delta.type === "text") {
process.stdout.write(event.delta.text);
}
}
console.log(); // 補一個換行,畫面收尾比較乾淨
}
main();
跟昨天的代碼比起來,改動不算大:多了 stream: true、把 console.log 換成迴圈裡的 process.stdout.write,另外多了一個 thinking_level: "minimal"——這行不是一開始就有的,是我在下面的踩坑記錄裡撞了好幾次牆才補上去的,先照抄不會錯,好奇原因可以往下看。
執行 node index.js,這次畫面不再是安靜等待、然後整句彈出,而是像有人在終端機裡即時打字一樣,文字一個字一個字接續出現。
雖然背後只是把「等待感」轉移到了視覺上,實際生成時間並沒有變快,但體感差異很明顯——看得到進度,等待就不再是空白的。
過濾條件不是一次就寫對的。
第一版代碼裡,我漏掉了 await,直接把 ai.interactions.create({ ...stream: true }) 的結果丟進 for await...of,結果終端機丟出:
TypeError: stream is not async iterable
原因是 interactions.create() 本身回傳的是一個 Promise,沒有 await,拿到的還是 Promise 物件本身,當然沒辦法直接迭代。補上 await 之後才正常。
第二個坑,是一開始沒有判斷 delta.type,把所有 step.delta 事件都印出來,混進了模型內部思考步驟的內容,畫面出現一堆看不懂的符號片段。加上 delta.type === "text" 這道過濾,畫面才乾淨下來——但也因為這樣,埋下了下面第三個、也是最花時間的坑。
真正的大魔王:從 injected env 到第一個字,足足等了 20 秒
加完 delta.type === "text" 過濾之後,畫面確實乾淨了,但換來一個新問題:從程式印出 injected env 開始,到真正吐出第一個字,中間有一次足足空等了快 20 秒,畫面上什麼都沒有。
第一直覺:是不是模型「思考」太久?但因為思考步驟的 delta 類型是 thought_signature(一段加密簽章,本來就不是文字),被我的過濾條件整個吃掉了,所以完全看不到它在想什麼、想了多久,只能用猜的。
先加時間戳偷看一下 step.start 事件:
for await (const event of stream) {
if (event.event_type === "step.start") {
console.log(`[${Date.now() - t0}ms] step:`, event.step.type);
}
}
結果看到的是 [3512ms] step: thought 接著 [3514ms] step: model_output——3.5 秒,跟 20 秒差很多。這時候懷疑方向整個歪掉,開始猜是不是連線問題:會不會是 Windows 網路環境對 Google API 的 IPv6 路由不通,卡在逾時重試上?
為了排除是不是 Node/SDK 端的問題,改用 curl 直接打 API。
使用 curl 自帶的計時參數 -w,一行重測:
curl -4 -o NUL -s -w "connect: %{time_connect}s first byte: %{time_starttransfer}s total: %{time_total}s\n" -X POST "https://generativelanguage.googleapis.com/v1beta/interactions" -H "x-goog-api-key: %GEMINI_API_KEY%" -H "Content-Type: application/json" -H "Api-Revision: 2026-05-20" --no-buffer -d "{\"model\":\"gemini-3-flash-preview\",\"input\":\"說一句話\",\"stream\":true}"
結果:
connect: 0.040131s first byte: 0.314314s total: 6.449674s
連線 40 毫秒、拿到第一個位元組 0.3 秒,都快得不像話——IPv6 繞路這個猜測直接出局。但要注意一個陷阱:SSE 串流的「第一個位元組」通常只是伺服器立刻回的 interaction.created 確認訊息,不是模型真正吐出的內容,所以它快,不代表回答也快,整段跑完還是花了 6.45 秒。
回頭查文件才發現真兇:gemini-3-flash-preview 這顆模型,沒指定 thinking_level 的話預設就是「高」,支援的等級最低可以到 minimal。也就是說,即使是「說一句話」這種完全不需要推理的 prompt,模型每次還是會自己決定要想多久,時間本來就會在幾秒到幾十秒之間跳動——這正是我一路測到 3.5 秒、4.4 秒、6.45 秒、乃至最初 20 秒的原因,跟網路、跟連線一點關係都沒有。
抓到兇手之後,修正很簡單:在 generation_config 裡加一行 thinking_level: "minimal"(就是上面第四段代碼裡那一行)。重新測過,等待時間穩定下來,不再忽快忽慢。
這個坑最大的收穫,其實不是這一行參數,而是排查的順序:先用時間戳把「猜測」變成「數字」,再用 curl 把 Node/SDK 徹底排除在外,最後才回頭查文件比對——比起憑感覺瞎猜,一步步縮小範圍靠譜得多。
打字機效果做出來了,但心裡冒出一個新的疑慮:
DevPulse 真正要的輸出,不是一句話,而是像 { issue: "...", suggestion: "..." } 這種結構化的 JSON,程式才能拿去接下一步邏輯。
如果照今天這樣一個字一個字印,畫面上會先看到半個 {、半個欄位名稱,更麻煩的是——如果程式想在串流過程中就直接 JSON.parse() 那段還沒收完整的字串,會直接噴錯崩潰。
打字機效果解決了「使用者體驗」,但也順手挖出了下一個問題:串流跟結構化輸出,好像天生有點衝突。
明天就來處理這件事,逼 Gemini 乖乖只吐乾淨的 JSON,不要廢話。